package com.opennews.openplatform

import com.opennews.openplatform.constants.ServerError
import com.opennews.openplatform.security.FullRole
import com.opennews.openplatform.security.MyClaims
import com.opennews.openplatform.security.MyUserDetails
import com.opennews.openplatform.security.TokenManager
import grails.compiler.GrailsCompileStatic
import grails.gorm.transactions.Transactional
import groovy.transform.CompileDynamic
import org.springframework.beans.factory.annotation.Value

import java.time.Instant

@Transactional
@GrailsCompileStatic
class AuthenticationService {
    @Value('${jwt.signingKey}')
    String signingKey

    @Value('${jwt.expiration}')
    int expiration

    TokenManager tokenManager
    UserService userService
    UserActiveTokenService userActiveTokenService

    /**
     * Authenticates user based on username and password.
     * @param username : String of username.
     * @param password : String of user raw password.
     * @param ipAddress : String of client request ip address.
     * @param deviceInfo : Map instance which contains device related info.
     * @param systemInfo : The map contains user device system info.
     * @return The map instance which contains keys below.
     *          myUserDetails: Instance of MyUserDetails.
     *          errorCode: String of server error code.
     */
    Map<String, ?> authenticateByCredential(String username, String password, String ipAddress, Map<String, String> deviceInfo, Map<String, String> systemInfo) {
        def myUserDetails = new MyUserDetails()
        def errorCode = ""

        if (userService.validatePassword(username, password)) {
            def user = userService.queryStatus(username)

            // Checks on user each status.
            if (!user.enabled) {
                errorCode = ServerError.ERROR_USER_ACCOUNT_DISABLED
            } else if (user.accountExpired) {
                errorCode = ServerError.ERROR_USER_ACCOUNT_EXPIRED
            } else if (user.accountLocked) {
                errorCode = ServerError.ERROR_USER_ACCOUNT_LOCKED
            } else if (user.passwordExpired) {
                errorCode = ServerError.ERROR_USER_PASSWORD_EXPIRED
            } else {
                myUserDetails = generateUserDetails(username)
                userActiveTokenService.updateTokens(myUserDetails, ipAddress, deviceInfo, systemInfo)
            }
        } else {
            // If the user input is not correct.
            // Actually we did not give info like 'user not found' or 'password is incorrect'.
            // This is for void brute force attack. If the attacker does NOT know the login id or password is correct that would make the attach more difficult.
            errorCode = ServerError.ERROR_USER_ACCOUNT_BAD_CREDENTIAL
        }

        return [
            myUserDetails: myUserDetails,
            errorCode: errorCode
        ]
    }

    /**
     * Authenticates user based on username and auth code.
     * @param username : String of username.
     * @param authCode : String of auth code.
     * @param deviceInfo : Map instance which contains device related info.
     * @return The map instance which contains keys below.
     *          myUserDetails: Instance of MyUserDetails.
     *          errorCode: String of server error code.
     */
    Map<String, ?> authenticateByAuthCode(String username, String authCode, Map<String, String> deviceInfo) {
        def myUserDetails = new MyUserDetails()
        def errorCode = ""

        return [
            myUserDetails: myUserDetails,
            errorCode: errorCode
        ]
    }

    /**
     * Refreshes access token with valid refresh token.
     * @param refreshToken : String of refresh token.
     * @param ipAddress : String of client request ip address.
     * @param deviceInfo : Map instance which contains device related info.
     * @param systemInfo : The map contains user device system info.
     * @return The map instance which contains keys below.
     *          myUserDetails: Instance of MyUserDetails.
     *          errorCode: String of server error code.
     */
    Map<String, ?> refreshToken(String refreshToken, String ipAddress, Map<String, String> deviceInfo, Map<String, String> systemInfo) {
        def myUserDetails = new MyUserDetails()
        def errorCode = ""
        def parsedToken = tokenManager.validateToken(refreshToken, signingKey)

        // If token is valid we still need to check if it is still active.
        if (parsedToken.valid) {
            parsedToken.valid = userActiveTokenService.isActiveRefreshToken(parsedToken.claims.username, refreshToken)
        }

        if (parsedToken.valid) {
            // Generates new details.
            myUserDetails = generateUserDetails(parsedToken.claims.username)

            // But keeps refresh token unchanged.
            myUserDetails.refreshToken = refreshToken

            // Updates new token as active.
            userActiveTokenService.updateTokens(myUserDetails, ipAddress, deviceInfo, systemInfo)
        } else {
            errorCode = ServerError.ERROR_USER_ACCOUNT_NOT_AUTHORIZED
        }

        return [
            myUserDetails: myUserDetails,
            errorCode: errorCode
        ]
    }

    void logout(String token) {
        userActiveTokenService.deleteByAccessToken(token)
    }

    /**
     * Switches user to account group by its id.
     * @param accountGroupId : String of account group id.
     * @param username : String of username.
     * @param password : String of user raw password.
     * @param ipAddress : String of client request ip address.
     * @param deviceInfo : Map instance which contains device related info.
     * @param systemInfo : The map contains user device system info.
     * @return The map instance which contains keys below.
     *          myUserDetails: Instance of MyUserDetails.
     *          errorCode: String of server error code.
     */
    @CompileDynamic
    MyUserDetails switchAccountGroup(String accountGroupId, String userId, String username, String ipAddress, Map<String, String> deviceInfo, Map<String, String> systemInfo) {
        def myUserDetails = new MyUserDetails()
        def accountGroup = AccountGroup.proxy(accountGroupId)

        // Checks if user has this account group permission.
        if (AccountGroupUser.countByAccountGroupAndUser(accountGroup, User.proxy(userId)) == 1) {
            // Updates user current account group to provided one.
            User.where {
                id == userId
            }.updateAll(accountGroup: accountGroup)

            // Generates MyUserDetails and token.
            myUserDetails = generateUserDetails(username)
            userActiveTokenService.updateTokens(myUserDetails, ipAddress, deviceInfo, systemInfo)
        }

        return myUserDetails
    }

    /**
     * Generates MyUserDetails instance based on username.
     * @param username : String of username.
     */
    @Transactional(readOnly = true)
    @CompileDynamic
    MyUserDetails generateUserDetails(String username) {
        def myUserDetails = new MyUserDetails()

        if (!SharedUtil.isNullOrEmpty(username)) {
            // Queries user details from db.
            def userDetails = userService.queryDetails(username)

            // For refresh token which does not need roles and it never expires.
            def claims = new MyClaims(
                accountGroupId: userDetails.accountGroupId.toString(),
                id: userDetails.id.toString(),
                username: username)

            // Generates refresh token. 0 means it never expires.
            def refreshTokenString = tokenManager.generateToken(claims, signingKey, 0)

            // Generates list of security.FullRole based on list of map item.
            List<FullRole> fullRoles = []

            FullRole fullRole

            // For claims usage.
            List<String> roleNames = []

            for (def role in (userDetails.fullRoles as List<Map<String, String>>)) {
                // Converts map item to FullRole.
                fullRole = new FullRole(role)

                // Adds it to role list.
                fullRoles << fullRole

                // Gets role name for claims.
                roleNames << fullRole.authority
            }

            // For access token which will expires in particular duration.
            claims.roles = roleNames

            // Generates access token.
            def accessTokenString = tokenManager.generateToken(claims, signingKey, expiration)

            // Prepares MyUserDetails data.
            myUserDetails.accountGroupId = userDetails.accountGroupId
            myUserDetails.id = userDetails.id
            myUserDetails.username = userDetails.username
            myUserDetails.fullName = userDetails.fullName
            myUserDetails.avatarUrl = userDetails.avatarUrl
            myUserDetails.isAccountGroupCreator = userDetails.isAccountGroupCreator
            myUserDetails.bearer = "bearer"
            myUserDetails.roles = claims.roles
            myUserDetails.fullRoles = fullRoles
            myUserDetails.accessToken = accessTokenString
            myUserDetails.refreshToken = refreshTokenString
            myUserDetails.expiresIn = expiration

            // Without toInstant() the value looks like Thu Apr 25 06:06:52 UTC 2024
            // With toInstant() the value looks like 2024-04-25T06:06:14Z
            // Both works on clientside but toInstant() produces value in China friendly standard UTC format.
            myUserDetails.generatedAt = Date.from(Instant.ofEpochSecond(claims.issuedAt)).toInstant()
            myUserDetails.expiredAt = Date.from(Instant.ofEpochSecond(claims.expiresAt)).toInstant()
        }

        return myUserDetails
    }
}